Dev ApiResponse
컨트롤러가 JSON 응답과 권한 거절 응답을 만드는 방식을 공용 트레이트로 모았다.
1. 문제
같은 사실이 여러 파일에 흩어져 적혀 있었다.
중복된 것 | 사본 수 | 위치 |
|---|---|---|
| 4 |
|
| 4 |
|
| 2 |
|
| 36 |
|
에러 봉투 | 66 |
|
| 13 |
|
권한 확인 → site 조회 → 404 사다리 | 12 |
|
에러 봉투가 특히 나빴다. 같은 {"error": "..."} 를 두 관용구로 적고 있었다. Json.obj("error" -> Json.fromString(msg)) 와 Map("error" -> msg).asJson 은 결과 바이트가 같지만 표기가 달라서, 둘을 맞춰 둘 장치가 없다. 봉투를 바꿔야 할 때 한쪽만 고쳐도 컴파일이 통과한다.
2. 공용 코드
2.1. JsonResults
app/controllers/JsonResults.scala. JSON 응답을 만드는 컨트롤러가 상속한다.
Ok(json: Json)— circeJson을application/json으로 내보낸다.JsonResult(status: Status, json: Json)—Ok이외의 상태 코드용.JsonError(status: Status, message: String)— 에러 봉투를 만드는 유일한 자리.
2.2. AdminAuth
app/controllers/AdminAuth.scala. 관리자 권한 판정과 거절 응답을 함께 둔다. 둘은 함께 바뀌기 때문이다.
isAdmin/isSiteAdmin(siteSeq)—AdminLogic위임.AccessDenied— 거절 응답.
Database 를 추상 멤버가 아니라 implicit 파라미터로 받는다. 컨트롤러의 database 는 val 이 아닌 implicit 생성자 파라미터라서 추상 멤버를 구현하지 못한다. 구현하게 하려면 클래스마다 val 을 붙여 공개 접근자를 늘려야 하는데, 그 대가로 얻는 것이 없다.
AccessDenied 는 val 이 아니라 def 다. 트레이트의 val 은 초기화 순서 경쟁에 걸려도 컴파일은 통과하고 생성 시점 NPE 로만 드러난다. Result 생성 비용은 그 위험을 질 만큼이 아니다.
2.3. withSiteAdmin / withAdminSite
ApiAdminSite 안의 private 헬퍼다(2026-08-10 컨트롤러 분리 때 Api 에서 옮겨 갔다). "권한을 확인하고, site 를 읽고, 없으면 404" 순서를 한 곳에 둔다.
private def withSiteAdmin(seq: Long)(block: Site => Result)(implicit request: RequestHeader): Result = if (!isSiteAdmin(seq)) AccessDenied else SiteLogic.get(seq)(database).fold(siteNotFound(seq))(block)
ApiAdminSite 밖으로 올리지 않았다. 호출부가 전부 ApiAdminSite 안에 있기 때문이다. 필요한 것보다 멀리 올리는 것은 그 자체로 결합이다.
3. 통일하지 않고 남겼던 것 — 2026-08-29 에 통일했다
1차 정리는 응답 바이트를 바꾸지 않았고, 아래 불일치를 의도적으로 남겼다. 클라이언트가 무엇을 파싱하는지 확인하지 않은 채 봉투를 바꾸면 조용히 깨지기 때문이다. 이번에 그 확인을 했고, 아래 셋을 통일했다. 남은 자리 하나는 이 절 끝에 있다.
-
ApiCrawler의{"message": ...}일곱 곳 →JsonError. 소비자는 링크 프리뷰(AhaWiki.PageView.js의showPreview) 하나이고, 오류에서 본문을 읽지 않는다 —console.log후 프리뷰를 지울 뿐이다. -
Api.pageRevision의 404 — **체크박스의 전제가 먼저 사라져 있었다.** 세 번째 봉투({"success": false, ...})를 쓰던 404 분기는 그 사이(fbc49b09) 없어졌고, 없는 페이지는 revision0으로 200 을 답한다. 남아 있던 것은{"status": "ok", "pageName", "revision"}라는 Play-JSON 성공 봉투였고, 유일한 소비자(AhaWiki.Kanban.js의fetchLatestRevision)가.revision만 읽으므로{"revision": N}으로 줄였다. -
AccessDenied→JsonError(Forbidden, "Access denied."). 관리자 UI 의 읽기 쪽fetchJson은!response.ok면 본문을 읽지 않고HTTP <status>로 던진다. 쓰기 쪽send와 계정 화면은 2026-08-31 부터{"error"}의error를 읽어 메시지로 쓴다 — 다른 모양의 거절 본문을 파싱하는 곳은 없다.Admin의 HTML 라우트도 같은 JSON 으로 답하게 됐는데, 그것이 결정이다: 관리자 UI 는 비관리자에게 그 경로를 보여주지 않고, 거절 모양 하나가 둘보다 낫다.
확인하다 **네 번째 봉투**도 나왔다. Api.renderAhaMark 가 {"status": "ok", "html"} / {"status": "error", "message"} 를 쓰고 있었다. 소비자(AhaWiki.Kanban.js 의 renderComment)가 성공에서 .html 만, 실패에서 payload.message || payload.error 를 읽어 **양쪽 봉투를 다 받아주게** 짜여 있어서 함께 통일했다 — 성공은 {"html": ...}, 실패는 JsonError. 이것으로 Api.scala 에서 Play-JSON import 가 사라졌다.
서버와 클라이언트가 같은 릴리스로 나가므로 이 확인 방식이 성립한다 — 외부에 문서화된 소비자가 있는 /api/v1 은 이번 범위에 없다.
3.1. 1차 정리에서 놓친 것
.toString()).as(JSON) 로 다시 훑어 손으로 봉투를 만드는 자리를 더 찾았다. 패턴으로 치환할 때는 치환 대상 목록 자체를 의심해야 한다는 뜻이다.
Api.adminGenerateSignedReadUrl— 트레이트에Ok(json)이 있는데 같은 식을 손으로 적고 있었다.ApiV1의 revision 충돌 응답 3벌 —revisionConflict로 뽑았다.latestRevision을 함께 실어야 해서JsonError로는 표현되지 않고JsonResult를 쓴다.Api.pageRevision의 404 — (뒤에 그 404 분기째 없어졌다) 봉투 모양은 호출부를 모르므로 그대로 두고JsonResult만 태웠다.
그래도 하나는 남아 있다. Api.pagePreview 의 404 는 {"success": false, "message"} 를, 403 은 같은 모양을 .toString()).as(JSON) 로 만든다. 소비자는 AhaWiki.PageView.js 의 showPreview 하나이고 오류 본문을 읽지 않으므로 통일해도 깨질 것은 없다 — 통일하려면 여기가 남은 자리다.
4. 목록 봉투
페이지네이션 목록은 {"array": [...], "page": N, "pageSize": N, "count": N} 하나로 답한다. JsonResults.pagedJson 이 만드는 유일한 자리다.
네 endpoint 가 이걸 손으로 만들고 있었고 **이미 갈라져 있었다** — 셋은 page·pageSize 를 보내고 ApiCrawler 는 array·count 만 보냈다. 관리자 UI 가 둘 다 받아주게 짜여 있어서 아무도 몰랐다. 네 번째 endpoint 로 페이징을 시작하는 클라이언트가 있었다면 필요한 필드가 없다는 걸 그때 발견했을 것이다.
푸는 쪽도 하나다. app/assets/js/admin/api.js 의 unwrapPaged 와 pagedParams 가 각각 응답 해체와 질의 파라미터 조립을 맡는다. hook 다섯 곳이 각자 풀고 있었다.
5. 에러 봉투는 compact 가 아니다
circe 의 Json.toString 은 compact 가 아니라 spaces2 로 찍는다. 실제 바이트는 아래와 같다.
{
"error" : "site not found: 999"
}리팩터링 전 두 관용구가 모두 Json.toString 을 거쳤으므로 이 형태였고, JsonError 도 같다. compact 라고 넘겨짚고 클라이언트에서 문자열을 비교하면 어긋난다.
6. 검증 결과
sbt compile성공sbt test성공 — 기존 테스트를 하나도 바꾸지 않았다- 중복 표기 137곳을 공용 헬퍼 호출로 치환
- 기존 컨트롤러 5개 순 -80줄, 새 트레이트 47줄을 더하면 순 -33줄
응답 바이트는 일회성 스펙으로 실제 앱을 띄워 라우터를 통과시켜 확인한 뒤, 앱을 소켓까지 띄우고 curl 로 다시 확인했다.
요청 | 응답 |
|---|---|
| 403 |
| 403 |
| 403 |
| 403 — site 조회보다 권한 확인이 먼저 |
| 401 |
| 403 |
| 200 |
| 200 |
모르는 site 를 물어도 외부인에게는 404 가 아니라 403 이 간다. 권한 확인이 먼저라, 응답이 site 의 존재 여부를 알려주지 않는다. siteSeq 를 파라미터로 받는 favicon·테마 endpoint 다섯도 resolveAdminTargetSiteWithAuth 에서 같은 순서다. 2026-09-10 까지는 그 다섯만 site 를 먼저 읽어서, 모르는 seq 에는 누구에게나 400 을, 있는 seq 에는 403 을 답했다 — 밖에서 어느 사이트 번호가 있는지 셀 수 있었다. ApiSiteAdminSpec 이 이제 둘 다 403 인 것을 지킨다.
withAdminSite 로 접은 endpoint 들의 상태 코드는 기존 ApiSiteAdminSpec 이 지키고, **봉투의 본문과 Content-Type 은 ApiErrorEnvelopeSpec 이 지킨다** — 위 표 중 /api/Admin/Sites, /Admin/Sites, /api/crawler 의 거절 봉투가 그 스펙의 단언이다. 나머지 줄은 curl 로만 확인했다. 1차 정리 때는 본문을 보는 검사가 없어서 일회성 curl 로 쟀는데, 검사 없는 모양이 셋 반까지 불어난 경위가 그것이다.
7. 로컬 실행
로컬 실행은 ~/.config/ahawiki/application.local.dev.conf 로 한다. 이 저장소에 없는 로컬 파일이다. 띄우기 전에 알아 둘 것이 둘 있다.
- 클래스패스의 캐시 구현은
play-redis뿐이다. caffeine 도 ehcache 도 없어서 Redis 없이는 서비스되지 않는다. Redis 모듈을 아예 켜지 않은 설정으로 띄우면 Guice 가SyncCacheApi바인딩을 찾지 못해 부팅 자체가 실패하고, 모듈은 켰는데 Redis 에 닿지 못하면 개발 모드의 서버는 포트를 열지만 Guice 가 인젝터를 만들지 못해 매 요청이 500 이 된다(Dev RunningLocally). base.conf의play.evolutions.db.default.autoApply = true가 여기에도 적용된다. 뭔가 확인하려고 띄우는 것이라면 꺼서 공유 DB 스키마를 건드리지 않게 한다.
sbt -Dconfig.file="$HOME/.config/ahawiki/application.local.dev.conf" \ -Dplay.evolutions.db.default.autoApply=false \ -Dhttp.port=9123 run
설정된 Redis 에 닿지 못하면 그 부분만 갈아끼우면 된다. 나머지는 설정이 가리키는 곳을 그대로 쓴다.
docker run -d --name ahawiki-local-redis -p 16379:6379 redis:7-alpine sbt -Dconfig.file="$HOME/.config/ahawiki/application.local.dev.conf" \ -Dplay.evolutions.db.default.autoApply=false \ -Dplay.cache.redis.host=localhost -Dplay.cache.redis.port=16379 \ -Dhttp.port=9123 run
7.1. Host 헤더가 site 를 고른다
SiteLogic.get(host) 가 요청 host 로 site 를 찾고, 못 찾으면 Site.notFound 로 떨어진다. 127.0.0.1:9123 으로 직접 부르면 페이지 목록이 빈 배열로 나오는데, 고장이 아니라 그 host 에 걸린 site 가 없다는 뜻이다. 실제 데이터를 보려면 실제 site 의 host 를 실어야 한다.
curl -H "Host: your.wiki.host" http://127.0.0.1:9123/api/pageNames
8. See Also
8.2. Similar Pages
Similar pages by cosine similarity. Words after page name are term frequency.